Skip to content

feat(oid4vc): add mDOC credential issuance and verification (ISO 18013-5)#22

Merged
burdettadam merged 39 commits intomainfrom
feat/mso-mdoc-new
Mar 11, 2026
Merged

feat(oid4vc): add mDOC credential issuance and verification (ISO 18013-5)#22
burdettadam merged 39 commits intomainfrom
feat/mso-mdoc-new

Conversation

@burdettadam
Copy link
Collaborator

mDOC support via isomdl-uniffi. Depends on #21.

…tion

Implements OID4VCI mso_mdoc credential issuance and OID4VP mDOC
presentation verification using the isomdl-uniffi Rust library.

Key changes:
- Rewrite mso_mdoc credential processor with isomdl-uniffi bindings
- Add mDOC issuer (mdoc/issuer.py) and verifier (mdoc/verifier.py)
- Add MSO issuer/verifier (consolidated from mso/ into mdoc/)
- Add key generation routes for mDOC signing keys
- Add storage layer: trust anchors, certificates, keys, config
- Add x.509 cert chain handling and PEM splitting utilities
- Add trust anchor guard (fail-closed) and cert expiry validation
- Remove superseded mso/ package and x509.py (merged into mdoc/)
- Update Docker/CI to install isomdl-uniffi platform wheel
- Add OID4VC conformance tests GitHub Actions workflow
- Fix ConnectError retry in integration test credo_client fixture

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
The upstream isomdl-uniffi library now exposes issuer_signed_b64() which
serialises directly to an IssuerSigned struct that carries the correct
serde rename attributes for ISO 18013-5 section 8.3 camelCase keys
(issuerAuth, nameSpaces) and array namespace values.

This removes the Python-side _patch_mdoc_keys workaround which had to
decode CBOR, rename keys by hand, and re-encode. The fix is now in the
right layer (Rust serialisation types) rather than a post-processing hack.

Change summary:
- Remove import base64 (only used by _patch_mdoc_keys)
- Remove _patch_mdoc_keys() entirely
- Replace stringify() + _patch_mdoc_keys() call with mdoc.issuer_signed_b64()
- Add test_mdoc_sign_emits_iso_cbor_keys to verify camelCase keys and
  array namespace values end-to-end through isomdl_mdoc_sign()

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Namespace element values are now passed to Mdoc.create_and_sign() as JSON
strings (stdlib json.dumps) rather than CBOR bytes (cbor2.dumps).

The Rust layer gains a json_to_cbor() converter so it internalises the
CBOR encoding, eliminating the need for callers to own a CBOR library.

Changes:
- mso_mdoc/mdoc/issuer.py: remove `import cbor2`; cbor2.dumps -> json.dumps
  in _prepare_mdl_namespaces and _prepare_generic_namespaces
- integration/tests/mdoc/test_pki.py: namespace inputs updated to json.dumps;
  cbor2 retained (hard import) for the DeviceResponse construction below
- pyproject.toml: cbor2 removed from [dependencies] optional and from
  mso_mdoc extras; added to [tool.poetry.group.dev.dependencies]

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Trust anchors are exclusively stored in and retrieved from the Askar
wallet. Sub-wallets maintain their own trust registry with their own
root authority certificates.

- Remove FileTrustStore (filesystem PEM directory) entirely
- Remove OID4VC_MDOC_TRUST_STORE_TYPE env var and create_trust_store()
- verify_credential / verify_presentation always build a fresh
  WalletTrustStore(profile) from the calling profile per-request,
  ensuring each tenant Askar partition is queried correctly
- Simplify plugin __init__.py / on_startup (no trust store init at startup)
- Remove TestFileTrustStore unit tests (class no longer exists)
- Rewrite test_wallet_trust_store_per_request.py for always-wallet design
- Remove FileTrustStore imports from test_review_issues / test_verifier

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
… API

- issuer.py: use create_and_sign_mdl for mDL path (accepts JSON strings,
  handles CBOR encoding internally); call issuer_signed_b64() for
  ISO 18013-5 compliant IssuerSigned CBOR output
- nonce.py: stringify bool tags for Askar compatibility (fixes token endpoint)
- demo/setup.sh: source .env before URL defaults so port overrides are honoured
- demo/demo.spec.ts: add required portrait and un_distinguishing_sign fields
  to mDL credential subject (required by OrgIso1801351::from_json)

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Avoids conflict with port 8021 on the host. The .env already used 8121/8122
but the docker-compose defaults fell back to 8021/8022 when .env was absent.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
NUXT_PUBLIC_ISSUER_CALLBACK_URL and waltid-proxy host port defaults
were 7101 but .env sets WALLET_PORT=7201; align docker-compose defaults
to avoid mismatch when running without .env overrides.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Issuer host ports: 8021/8022 → 8121/8122
Wallet host port:  7101 → 7201

Keeps documented defaults consistent with the compose file so that
a bare 'cp .env.example .env' works without manual edits.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- zrok reserve example: localhost:8022 → localhost:8122
- comment: default ports (8021/8022) → (8121/8122)

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Issuer admin:  8021 → 8121
Issuer OID4VCI: 8022 → 8122
Wallet proxy:  7101 → 7201

Updated in: quick start, services table, zrok example,
architecture diagram, and curl examples.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- Opening statement now references OID4VCI 1.0 final
- Footer link updated from -1_0-11.html to -1_0.html
- Drop 'experimental' / 'in active development' language now
  that the final spec is published

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
OID4VCI 1.0 Appendix B.2 / E.2.1 requires claims to be a non-empty
array of claim descriptor objects, not a namespace-keyed map.

transform_issuer_metadata() now converts the stored format:
  {namespace: {claim_name: {mandatory, display}}}
to the spec-compliant array form:
  [{path: [namespace, claim_name], mandatory: ..., display: ...}]

Add unit tests covering the claims transform, COSE alg conversion,
and the no-op case when claims is already an array.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Per OID4VCI 1.0 Appendix A.2.2 and Appendix B.2, the claims field in
mso_mdoc credential issuer metadata must be a flat array of claim
description objects with path: [namespace, claim_name], not a
namespace-keyed dict.

The stored format_data.claims remains {namespace: {claim_name: descriptor}}
for backwards compatibility. transform_issuer_metadata() now converts this
to the spec-mandated array form on the way out:

Before: {'org.iso.18013.5.1': {'given_name': {'mandatory': true}}}
After:  [{'path': ['org.iso.18013.5.1', 'given_name'], 'mandatory': true}]

Update unit tests to assert the correct array output.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Per OID4VCI 1.0 Section 12.2.4 and Appendix A.2.2, the claims array
and display array for mso_mdoc credentials must be nested inside
credential_metadata, not emitted at the top level of the credential
configuration object.

transform_issuer_metadata now:
- Pops 'claims' (namespace-dict) → converts to path-array
  [{path: [namespace, claim_name], mandatory, display}]
- Pops 'display' from the metadata top level
- Places both inside credential_metadata per spec

Output structure:
  {
    'format': 'mso_mdoc',
    'doctype': '...',
    'credential_signing_alg_values_supported': [-7],
    'credential_metadata': {
      'display': [...],
      'claims': [{path: [ns, name], mandatory: true}, ...]
    }
  }

Update unit tests accordingly.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Wallets such as walt.id include an explicit :443 in the proof JWT aud
(https://issuer.example.com:443) even though HTTPS 443 is the default port.
Per RFC 3986 these URLs are semantically identical to the same URL without
the port, but string comparison fails.

Strip the default port from both the aud values and the configured issuer
endpoint before comparing so https://host:443 == https://host.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
…y material

Some draft-era wallets (e.g. walt.id) send proof JWTs where the header
contains only alg and typ — no jwk, kid, or x5c. Instead they put their
DID in the payload iss claim (e.g. did:key:...) and expect the server to
resolve the verification key from it.

When no key material is found in the proof header, fall back to decoding
the payload and attempting key_material_for_kid() on the iss claim. Also
derive holder_jwk from the resolved key so mso_mdoc DeviceKey binding
works correctly.

Also tighten holder_jwk derivation to cover the iss-resolved path (not
just kid-resolved path).

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
…ling

- Remove module-level _mso_mdoc_processor variable and its Optional import;
  the processor is only used locally within setup() so no global needed
- Remove try/except in on_startup that was silently swallowing errors;
  plugin startup failures should propagate and fail loudly
- Fix key_material_for_kid call in token.py to pass a DID URL (with
  fragment) rather than a bare DID, using #0 as fallback fragment

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Auto-generating a self-signed test key at startup is inappropriate for
production deployments and masks misconfiguration. Replace with a clear
warning directing operators to use the admin API to provision keys.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- Add SUPPORTED_ALGS module-level constant (EdDSA, ES256) as single
  source of truth for accepted algorithms
- Validate presence of required 'alg' header (RFC 7515 §4.1.1) with
  clear error message and spec citation
- Reject unsupported alg values listing accepted options
- Enforce mutual exclusivity of kid/jwk/x5c key-identification params
  (RFC 7515 §4.1) with explicit conflict reporting
- Raise descriptive errors when none of kid/jwk/x5c are present
- Extend jwt_verify key resolution to support all three methods (jwk,
  kid, x5c) consistent with handle_proof_of_posession
- Improve alg/key-type mismatch error messages to describe the conflict

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
resolve_signing_key_for_credential was storing the generated key but
never calling store_config, so get_default_signing_key had to fall back
to list_keys()[0] — which is unreliable when multiple keys exist.

- Add store_config("default_signing_key", {"key_id": "default"}) inside
  the same try block as store_signing_key so the config is only written
  when the key actually persisted.
- Add a comment in _resolve_signing_key explaining why the return value
  of resolve_signing_key_for_credential is intentionally discarded
  (it returns a raw JWK; this method needs the full key_data struct).
- Add regression tests in TestResolveSigningKeyPersistsDefaultConfig and
  TestResolveSigningKeyUsesGeneratedKey covering both bugs.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
…ules

key_routes.py mixed signing key/certificate management with trust anchor
management — two unrelated concerns in one 460-line file.

- key_routes.py: now covers only signing key and certificate schemas,
  handlers, and route registration (register_key_routes).
- trust_anchor_routes.py (new): trust anchor schemas, handlers, and
  route registration (register_trust_anchor_routes).
- routes.py: imports and calls both register functions separately.
- register_key_management_routes alias kept in key_routes.py for
  backward compatibility.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Store a self-signed certificate whenever a new signing key is generated
in resolve_signing_key_for_credential (both the default-key path and the
verification-method path), so every key always has a certificate on record.

Replace the on-demand fallback in issue() and the mdoc_sign route handler
with a hard CredProcessorError / ValueError: missing certificates are now
a programmer error, not a silent auto-repair.

Remove unused imports from routes.py (uuid, datetime, timedelta,
generate_self_signed_certificate) that were only needed by the removed
fallback block.

Add tests:
- TestResolveSigningKeyStoresCertOnGeneration: verifies a cert is stored
  for both generation paths and is NOT stored for pre-existing keys.
- TestMissingCertRaisesCredProcessorError: verifies the hard error fires
  when get_certificate_for_key returns None.
- Update TestResolveSigningKeyPersistsDefaultConfig to mock
  generate_self_signed_certificate alongside generate_ec_key_pair so
  the fake PEM string does not reach the real certificate builder.

Signed-off-by: Adam Burdett <adam@indicio.tech>
test_review_issues.py was named after an internal code-review artifact.
Rename to test_cred_processor_and_verifier_unit.py, which accurately
describes the four areas covered: MsoMdocCredProcessor, MsoMdocCredVerifier /
MsoMdocPresVerifier / WalletTrustStore, key-generation and certificate
utilities, and mso_mdoc storage operations.

Update the module docstring accordingly.

Signed-off-by: Adam Burdett <adam@indicio.tech>
…ting key

When resolve_signing_key_for_credential is called with a verification_method
that is not in storage, silently generating a new random key and binding it
to that VM ID is incorrect: the generated key has no relationship to the DID
document's actual key material for that method.

Raise CredProcessorError with a clear message directing the operator to
register the key via the key management API before issuing.

Update tests: replace test_verification_method_key_generation_stores_certificate
(which tested the wrong behaviour) with:
- test_unknown_verification_method_raises: asserts CredProcessorError is raised
  and storage is not touched.
- test_known_verification_method_returned_without_cert_write: asserts an
  existing VM key is returned immediately without writing a certificate.

Signed-off-by: Adam Burdett <adam@indicio.tech>
No signing key configured is now a hard CredProcessorError in both
resolve_signing_key_for_credential and _resolve_signing_key. Operators
must register keys explicitly via the key management API.

Remove now-unused imports: uuid, timedelta, StorageError,
generate_ec_key_pair, generate_self_signed_certificate.

Update tests: replace generation/storage side-effect assertions with
tests that assert CredProcessorError is raised when no key is configured.
Add direct test for _resolve_signing_key raising when storage is empty.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Bug 1 - store_config overwrites operator default:
  Only call store_config when "default_signing_key" config is absent.
  Previously any run that loaded the env-var key for the first time
  would silently replace whatever key the operator had registered.

Bug 2 - inconsistent storage API:
  Replace get_key (returns raw JWK only) with get_signing_key
  (returns the full record, consistent with every other call-site
  in _resolve_signing_key).

Bug 3 - silent failure masked by misleading error:
  Replace bare except/log with re-raise as CredProcessorError with
  a message that names the failing file. Previously a bad PEM raised
  "No default signing key is configured" with no indication of why.

Add TestStaticEnvVarKeyLoading with four tests that each expose
one of the above bugs (plus the happy-path complement for Bug 1).

Signed-off-by: Adam Burdett <adam@indicio.tech>
…oad modules

Extract standalone functions from cred_processor.py into focused modules:

- signing_key.py: check_certificate_not_expired + resolve_signing_key_for_credential
- payload.py: prepare_mdoc_payload + normalize_mdoc_result

cred_processor.py retains MsoMdocCredProcessor and re-exports the extracted
public names. Private methods _prepare_payload and _normalize_mdoc_result remain
as one-liner delegates to preserve the existing test API.

Update 6 test patch paths from mso_mdoc.cred_processor.MdocStorageManager to
mso_mdoc.signing_key.MdocStorageManager for tests of the standalone
resolve_signing_key_for_credential function; update debug-log capture logger
from mso_mdoc.cred_processor to mso_mdoc.payload.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Add cryptography >=42 as a direct dependency of the oid4vc plugin.
This allows the `from cryptography import x509` import in signing_key.py
to sit at the top of the module rather than inside the function body,
avoiding the PLC0415 lint exemption.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
…, and pres_verifier modules

verifier.py was ~839 lines. Split into three focused modules:

- trust_store.py: TrustStore protocol + WalletTrustStore
- cred_verifier.py: MsoMdocCredVerifier + parsing helpers
  (PreverifiedMdocClaims, _parse_string_credential, _extract_mdoc_claims)
- pres_verifier.py: MsoMdocPresVerifier + OID4VP helpers + mdoc_verify
  (extract_mdoc_item_value, extract_verified_claims, MdocVerifyResult)

verifier.py is now a thin re-exporter for backward compatibility.

Update all test patch targets to reference the new module paths:
- mso_mdoc.mdoc.verifier.isomdl_uniffi -> cred_verifier / pres_verifier
- mso_mdoc.mdoc.verifier.Config -> pres_verifier
- mso_mdoc.mdoc.verifier.retrieve_or_create_did_jwk -> pres_verifier
- mso_mdoc.mdoc.verifier.MdocStorageManager -> trust_store

Tests that exercise mdoc_verify() also patch cred_verifier.isomdl_uniffi
since _parse_string_credential lives there and holds its own module reference.

Signed-off-by: Adam Burdett <adam@indicio.tech>
Split verifier.py into focused modules (trust_store.py, cred_verifier.py,
pres_verifier.py). Further split pres_verifier.py into mdoc_item.py and
mdoc_verify.py for better separation of concerns.

Also strip C-N and M-N audit report item labels from comments and
docstrings throughout the mso_mdoc plugin, as the referenced report
has been removed.

Changes:
- Split verifier.py → trust_store.py, cred_verifier.py, pres_verifier.py
- Split pres_verifier.py → mdoc_item.py, mdoc_verify.py
- Update all imports to use focused modules
- Remove audit report labels (C-N, M-N prefixes) from comments

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: 3f616aa
I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: 756d853
I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: d2286cd
I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: 76c48e3
I, Adam Burdett <burdettadam@gmail.com>, hereby add my Signed-off-by to this commit: e65f290

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- Remove unused json import from payload.py
- Remove unused ProfileSession import from cred_processor.py
- Remove unnecessary ellipsis from TrustStore protocol

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
- Remove unused MdocStorageManager import from pres_verifier.py
- Move flatten_trust_anchors import to top level in cred_verifier.py
- Apply ruff formatting to all oid4vc files

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Import generate_self_signed_certificate into cred_processor module
namespace so test_expired_certificate.py can patch it at
mso_mdoc.cred_processor.generate_self_signed_certificate.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Signed-off-by: Adam Burdett <burdettadam@gmail.com>
v0.1.0-test9 was missing create_and_sign_mdl() and issuer_signed_b64()
which caused 5 test failures and 4 errors in the mso_mdoc test suite.
v0.1.0-test11 includes these methods and all tests now pass.

Signed-off-by: Adam Burdett <burdettadam@gmail.com>
Copy link

@Eldersonar Eldersonar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@burdettadam burdettadam merged commit 08a3649 into main Mar 11, 2026
4 of 6 checks passed
@burdettadam burdettadam deleted the feat/mso-mdoc-new branch March 11, 2026 19:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants